/**
 * Copyright 2018-present Facebook.
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 * @format
 */

import LogManager from '../fb-stubs/Logger';
const fs = require('fs');
const adb = require('adbkit-fb');
import {
  openssl,
  isInstalled as opensslInstalled,
} from './openssl-wrapper-with-promises';
const path = require('path');

// Desktop file paths
const os = require('os');
const caKey = getFilePath('ca.key');
const caCert = getFilePath('ca.crt');
const serverKey = getFilePath('server.key');
const serverCsr = getFilePath('server.csr');
const serverCert = getFilePath('server.crt');

// Device file paths
const csrFileName = 'app.csr';
const deviceCAcertFile = 'sonarCA.crt';
const deviceClientCertFile = 'device.crt';

const caSubject = '/C=US/ST=CA/L=Menlo Park/O=Sonar/CN=SonarCA';
const serverSubject = '/C=US/ST=CA/L=Menlo Park/O=Sonar/CN=localhost';
const minCertExpiryWindowSeconds = 24 * 60 * 60;
const appNotDebuggableRegex = /debuggable/;
const allowedAppNameRegex = /^[a-zA-Z0-9.\-]+$/;
const allowedAppDirectoryRegex = /^\/[ a-zA-Z0-9.\-\/]+$/;

export type SecureServerConfig = {|
  key: Buffer,
  cert: Buffer,
  ca: Buffer,
  requestCert: boolean,
  rejectUnauthorized: boolean,
|};

/*
 * This class is responsible for generating and deploying server and client
 * certificates to allow for secure communication between sonar and apps.
 * It takes a Certificate Signing Request which was generated by the app,
 * using the app's public/private keypair.
 * With this CSR it uses the sonar CA to sign a client certificate which it
 * deploys securely to the app.
 * It also deploys the sonar CA cert to the app.
 * The app can trust a server if and only if it has a certificate signed by the
 * sonar CA.
*/
export default class CertificateProvider {
  logger: LogManager;
  adb: any;
  certificateSetup: Promise<void>;
  server: Server;

  constructor(server: Server, logger: LogManager) {
    this.logger = logger;
    this.adb = adb.createClient();
    this.certificateSetup = this.ensureServerCertExists();
    this.server = server;
  }

  processCertificateSigningRequest(
    csr: string,
    os: string,
    appDirectory: string,
  ): Promise<void> {
    this.ensureOpenSSLIsAvailable();
    if (!appDirectory.match(allowedAppDirectoryRegex)) {
      return Promise.reject(
        new Error(
          `Invalid appDirectory recieved from ${os} device: ${appDirectory}`,
        ),
      );
    }
    return this.certificateSetup
      .then(_ => this.getCACertificate())
      .then(caCert =>
        this.deployFileToMobileApp(
          appDirectory,
          deviceCAcertFile,
          caCert,
          csr,
          os,
        ),
      )
      .then(_ => this.generateClientCertificate(csr))
      .then(clientCert =>
        this.deployFileToMobileApp(
          appDirectory,
          deviceClientCertFile,
          clientCert,
          csr,
          os,
        ),
      );
  }

  ensureOpenSSLIsAvailable(): void {
    if (!opensslInstalled()) {
      const e = Error(
        "It looks like you don't have OpenSSL installed. Please install it to continue.",
      );
      this.server.emit('error', e);
    }
  }

  getCACertificate(): Promise<string> {
    return new Promise((resolve, reject) => {
      fs.readFile(caCert, (err, data) => {
        if (err) {
          reject(err);
        } else {
          resolve(data.toString());
        }
      });
    });
  }

  generateClientCertificate(csr: string): Promise<string> {
    this.logger.warn('Creating new client cert', 'CertificateProvider');
    const csrFile = this.writeToTempFile(csr);
    // Create a certificate for the client, using the details in the CSR.
    return openssl('x509', {
      req: true,
      in: csrFile,
      CA: caCert,
      CAkey: caKey,
      CAcreateserial: true,
    }).then(cert => {
      fs.unlinkSync(csrFile);
      return cert;
    });
  }

  deployFileToMobileApp(
    destination: string,
    filename: string,
    contents: string,
    csr: string,
    os: string,
  ) {
    if (os === 'Android') {
      this.extractAppNameFromCSR(csr).then(app => {
        const client = adb.createClient();
        client.listDevices().then((devices: Array<{id: string}>) => {
          devices.forEach(d =>
            // To find out which device requested the cert, search them
            // all for a matching csr file.
            // It's not important to keep these secret from other apps.
            // Just need to make sure each app can find it's own one.
            this.androidDeviceHasMatchingCSR(destination, d.id, app, csr)
              .catch(e =>
                this.logger.error(
                  `Unable to check for matching CSR in ${d.id}:${app}`,
                  'CertificateProvider',
                ),
              )
              .then(isMatch => {
                if (isMatch) {
                  this.pushFileToAndroidDevice(
                    d.id,
                    app,
                    destination + filename,
                    contents,
                  );
                }
              }),
          );
        });
      });
    }
    if (os === 'iOS') {
      fs.writeFileSync(destination + filename, contents);
    }
  }

  androidDeviceHasMatchingCSR(
    directory: string,
    deviceId: string,
    processName: string,
    csr: string,
  ): Promise<boolean> {
    return this.executeCommandOnAndroid(
      deviceId,
      processName,
      `cat ${directory + csrFileName}`,
    ).then(deviceCsr => {
      return (
        deviceCsr
          .toString()
          .replace(/\r/g, '')
          .trim() === csr.replace(/\r/g, '').trim()
      );
    });
  }

  pushFileToAndroidDevice(
    deviceId: string,
    app: string,
    filename: string,
    contents: string,
  ): Promise<void> {
    this.logger.warn(
      `Deploying sonar certificate to ${deviceId}:${app}`,
      'CertificateProvider',
    );
    return this.executeCommandOnAndroid(
      deviceId,
      app,
      `echo "${contents}" > ${filename} && chmod 600 ${filename}`,
    ).then(output => undefined);
  }

  executeCommandOnAndroid(
    deviceId: string,
    user: string,
    command: string,
  ): Promise<string> {
    if (!user.match(allowedAppNameRegex)) {
      return Promise.reject(new Error(`Disallowed run-as user: ${user}`));
    }
    if (command.match(/[']/)) {
      return Promise.reject(
        new Error(`Disallowed escaping command: ${command}`),
      );
    }
    return this.adb
      .shell(deviceId, `echo '${command}' | run-as '${user}'`)
      .then(adb.util.readAll)
      .then(buffer => buffer.toString())
      .then(output => {
        const matches = output.match(appNotDebuggableRegex);
        if (matches) {
          const e = new Error(
            `Android app ${user} is not debuggable. To use it with sonar, add android:debuggable="true" to the application section of AndroidManifest.xml`,
          );
          this.server.emit('error', e);
          throw e;
        }
        return output;
      });
  }

  extractAppNameFromCSR(csr: string): Promise<string> {
    const csrFile = this.writeToTempFile(csr);
    return openssl('req', {in: csrFile, noout: true, subject: true})
      .then(subject => {
        fs.unlink(csrFile);
        return subject;
      })
      .then(subject => {
        return subject
          .split('/')
          .filter(part => {
            return part.startsWith('CN=');
          })
          .map(part => {
            return part.split('=')[1].trim();
          })[0];
      })
      .then(appName => {
        if (!appName.match(allowedAppNameRegex)) {
          throw new Error(
            `Disallowed app name in CSR: ${appName}. Only alphanumeric characters and '.' allowed.`,
          );
        }
        return appName;
      });
  }

  loadSecureServerConfig(): Promise<SecureServerConfig> {
    return this.certificateSetup.then(() => {
      return {
        key: fs.readFileSync(serverKey),
        cert: fs.readFileSync(serverCert),
        ca: fs.readFileSync(caCert),
        requestCert: true,
        rejectUnauthorized: true, // can be false if necessary as we don't strictly need to verify the client
      };
    });
  }

  ensureCertificateAuthorityExists(): Promise<void> {
    if (!fs.existsSync(caKey)) {
      return this.generateCertificateAuthority();
    }
    return this.checkCertIsValid(caCert).catch(e =>
      this.generateCertificateAuthority(),
    );
  }

  checkCertIsValid(filename: string): Promise<void> {
    if (!fs.existsSync(filename)) {
      return Promise.reject();
    }
    return openssl('x509', {
      checkend: minCertExpiryWindowSeconds,
      in: filename,
    })
      .then(output => undefined)
      .catch(e => {
        this.logger.warn(
          `Certificate will expire soon: ${filename}`,
          'CertificateProvider',
        );
        throw e;
      });
  }

  verifyServerCertWasIssuedByCA() {
    const options = {CAfile: caCert};
    options[serverCert] = false;
    return openssl('verify', options).then(output => {
      const verified = output.match(/[^:]+: OK/);
      if (!verified) {
        // This should never happen, but if it does, we need to notice so we can
        // generate a valid one, or no clients will trust our server.
        throw new Error('Current server cert was not issued by current CA');
      }
    });
  }

  generateCertificateAuthority(): Promise<void> {
    if (!fs.existsSync(getFilePath(''))) {
      fs.mkdirSync(getFilePath(''));
    }
    this.logger.info('Generating new CA', 'CertificateProvider');
    return openssl('genrsa', {out: caKey, '2048': false})
      .then(_ =>
        openssl('req', {
          new: true,
          x509: true,
          subj: caSubject,
          key: caKey,
          out: caCert,
        }),
      )
      .then(_ => undefined);
  }

  ensureServerCertExists(): Promise<void> {
    if (
      !(
        fs.existsSync(serverKey) &&
        fs.existsSync(serverCert) &&
        fs.existsSync(caCert)
      )
    ) {
      return this.generateServerCertificate();
    }

    return this.checkCertIsValid(serverCert)
      .then(_ => this.verifyServerCertWasIssuedByCA())
      .catch(e => this.generateServerCertificate());
  }

  generateServerCertificate(): Promise<void> {
    return this.ensureCertificateAuthorityExists()
      .then(_ => {
        this.logger.warn('Creating new server cert', 'CertificateProvider');
      })
      .then(_ => openssl('genrsa', {out: serverKey, '2048': false}))
      .then(_ =>
        openssl('req', {
          new: true,
          key: serverKey,
          out: serverCsr,
          subj: serverSubject,
        }),
      )
      .then(_ =>
        openssl('x509', {
          req: true,
          in: serverCsr,
          CA: caCert,
          CAkey: caKey,
          CAcreateserial: true,
          out: serverCert,
        }),
      )
      .then(_ => undefined);
  }

  writeToTempFile(content: string): string {
    const fileName = getFilePath(`deviceCSR-${Math.random() * 1000000}`);
    fs.writeFileSync(fileName, content);
    return fileName;
  }
}

function getFilePath(fileName: string): string {
  return path.resolve(os.homedir(), '.sonar', 'certs', fileName);
}
